Skip to content

fix(hnsw): reject index files whose vector count overflows size_t#332

Merged
singaraiona merged 1 commit into
RayforceDB:devfrom
belowzeroff:fix/hnsw-load-overflow
Jul 21, 2026
Merged

fix(hnsw): reject index files whose vector count overflows size_t#332
singaraiona merged 1 commit into
RayforceDB:devfrom
belowzeroff:fix/hnsw-load-overflow

Conversation

@belowzeroff

@belowzeroff belowzeroff commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

What & why

ray_hnsw_load / ray_hnsw_mmap read n_nodes and dim directly from the
on-disk index header and sized the vectors allocation as
n_nodes * dim * sizeof(float) with no overflow check (the vectors block of
hnsw_load_impl in src/store/hnsw.c). A crafted header can make that product
wrap size_t, so ray_sys_alloc under-allocates the buffer while the following
fread(vecs, sizeof(float), vec_count, f) still reads the full (large) element
count — writing past the allocation. That is a heap-overflow write driven
entirely by an untrusted index file (an attacker who can supply a matching
hnsw_header.bin + hnsw_vectors.bin).

The per-layer neighbor arrays a few lines down already carry a divide-based
overflow guard; the vectors block was missing the equivalent. This adds the same
guard in header validation, so an overflowing file is rejected before any
allocation:

if ((uint64_t)hdr.n_nodes > SIZE_MAX / sizeof(float) / (uint64_t)hdr.dim)
    return NULL;

n_nodes and dim are already validated > 0 at that point, so the divide is
safe.

Test

embedding/hnsw_load_overflow_header_rejected saves a valid index, patches the
header's n_nodes/dim to an overflowing pair, and asserts the load is refused.
It lives at the C layer rather than under test/rfl/ because it needs byte-level
manipulation of the binary header, which the behavioural harness can't express.

Full suite under the debug ASan + UBSan build: 3632 of 3633 passed (1 skipped, 0 failed).

Checklist

  • PR targets dev (not master)
  • Commits follow Conventional Commits (fix:)
  • make builds cleanly (no new warnings)
  • make test passes; tests added/updated for behaviour changes

@singaraiona

Copy link
Copy Markdown
Collaborator

The regression test does not specifically exercise the new vector-size overflow guard. Only the header is patched: hnsw_levels.bin still contains 3 bytes, while bad_n_nodes makes the loader expect 2^40 bytes. With the guard removed, the loader still returns NULL earlier—either the 1 TiB node-level allocation fails, or the short levels read at hnsw.c:933-936 does—so it never reaches the vectors calculation. Please make the test distinguish this validation path, for example by factoring the header-size validation into a helper that can be tested directly.

@belowzeroff
belowzeroff force-pushed the fix/hnsw-load-overflow branch from f1897d0 to b8d2f5d Compare July 18, 2026 16:13
@belowzeroff

Copy link
Copy Markdown
Contributor Author

Thank you. The header-patch test couldn't reach the vectors guard (the oversized node-level read fails first), so it didn't distinguish the path. I factored the check into ray_hnsw_vec_size_valid(n_nodes, dim) and replaced the test with a direct unit test (ordinary dims, non-positive, an overflowing pair, and the exact SIZE_MAX/sizeof(float) boundary); it fails if the guard logic regresses. Amended in b8d2f5d.

hnsw_load_impl read n_nodes and dim straight from the file header and
sized the vectors allocation as n_nodes * dim * sizeof(float) with no
overflow check. A crafted header could make that product wrap size_t, so
ray_sys_alloc under-allocated the buffer while the following fread still
read the full (large) element count and wrote past the allocation — a
heap-overflow write driven by an untrusted index file.

Factor the check into ray_hnsw_vec_size_valid(n_nodes, dim) and reject the
header before any allocation, mirroring the per-layer neighbor guard.

Add a unit test that drives the helper directly (ordinary dims, non-positive
dims, an overflowing pair, and the exact size_t boundary). It is tested at
the helper rather than through ray_hnsw_load because an overflow-patched
header is refused earlier — the huge node-level read fails first — so a
full-load test could not distinguish the guard.
@belowzeroff
belowzeroff force-pushed the fix/hnsw-load-overflow branch from b8d2f5d to 5735507 Compare July 21, 2026 13:44
@singaraiona
singaraiona merged commit ebadd07 into RayforceDB:dev Jul 21, 2026
15 of 17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants